Skip to content

fix: stamp the template's complete/index.md so its self-heal stops causing drift - #120

Merged
Jammy2211 merged 5 commits into
mainfrom
feature/spawn-template-index-selfheal-drift
Aug 4, 2026
Merged

fix: stamp the template's complete/index.md so its self-heal stops causing drift#120
Jammy2211 merged 5 commits into
mainfrom
feature/spawn-template-index-selfheal-drift

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Follow-on to #119 / #118, found by dispatching Spawn Drift after publishing rather than trusting a local --check.

The problem

The template is not inert. It ships .github/workflows/lifecycle_drift.yml, whose self-heal (#116) regenerates complete/index.md on every push to the template's own main. But MIND_RULES maps complete/*DROP, so spawn never produced that file.

Result: a self-sustaining drift loop. Every sync is followed within seconds by a bot commit creating a file spawn does not generate, which the next --check reports as drift — forever.

Observed immediately:

17:28:51Z  51f5ae58  Jammy2211            spawn: regenerate from mind@ebd60f3
17:29:12Z  79864dde  github-actions[bot]  lifecycle: self-heal stale complete/index.md

and the next dispatch (run 30934170549) failed with only in published: complete/index.md.

This surfaced now because the self-heal itself only reached the template in that same sync — it was one of the five source drifts outstanding since 2026-07-27.

The fix

spawn runs the generated tree's own scripts/lifecycle.py index --apply after writing the tree. lifecycle.py resolves its root from __file__, and rule 1 already KEEPs it, so the template stamps its own index with the same code, over the same empty archive, as its self-heal would.

Verified byte-identical against the bot's actual commit.

Deliberately not a constant asset inside spawn: lifecycle.py owns the index format, and a second copy of that text would drift from it the next time the format changes.

The live complete/index.md is still DROPped by rule 7 — a fresh-slate template gets an empty-archive index, not the live one's wall of instance task slugs. test_live_complete_index_is_never_copied pins that.

Verification

  • 55 tests pass. The new tests/test_spawn_template_contract.py fails (2 of 3 tests) when the stamp is removed — control-tested, not assumed.
  • --check against the live published repos now reports the complete/index.md drift gone; the only remaining drift is scripts/spawn.py itself, which clears when this merges and the templates are re-synced.

API Changes

None. spawn.py is a repo-local generator with no importers outside this repo. New module-level helper stamp_complete_index(); no signature changes to existing functions.

Post-merge

Re-run /spawn --apply to publish this, then the next scheduled Spawn Drift should be green without a human touching it — which is the actual point of the exercise.

🤖 Generated with Claude Code

Jammy2211 and others added 2 commits August 4, 2026 18:49
…using drift

The template is not inert. It ships `.github/workflows/lifecycle_drift.yml`,
whose self-heal (#116) regenerates `complete/index.md` on every push to the
template's own `main` — but `MIND_RULES` DROPs `complete/*`, so spawn never
produced that file. Every sync was therefore followed within seconds by a bot
commit creating a file the next `--check` reported as drift, permanently.

Observed on 2026-08-04: sync 51f5ae58 at 17:28:51Z, bot commit 79864dde at
17:29:12Z, and the next dispatch (run 30934170549) failed with
`only in published: complete/index.md`.

The self-heal only reached the template in that same sync — it was one of the
five source drifts outstanding since 2026-07-27 — which is why this surfaced
now rather than earlier.

spawn now runs the GENERATED tree's own `scripts/lifecycle.py index --apply`
after writing (lifecycle.py resolves its root from `__file__`, and rule 1
already KEEPs it). Byte-identical to the self-heal's output by construction,
verified against the bot's commit. Deliberately not a constant asset here:
lifecycle.py owns the index format and a second copy would drift from it.

The live `complete/index.md` is still DROPped by rule 7 — a fresh template gets
an empty-archive index, not the live one's wall of task slugs.

Adds spec rule 6c and tests/test_spawn_template_contract.py, which fails if the
stamp is removed.

Refs #118

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Jammy2211 Jammy2211 added the pending-release Awaiting coordinated release label Aug 4, 2026
Jammy2211 and others added 3 commits August 4, 2026 18:50
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The privacy job named tests/test_spawn_privacy.py explicitly, so
tests/test_spawn_template_contract.py added in the previous commit never ran in
CI — the run reported 52 passed while the suite is 55. Naming a suite by
filename means anything added beside it is silently uncovered.

Also widens the pull_request paths filter to tests/** so a PR touching only a
test file still triggers the job.

Refs #118

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Jammy2211
Jammy2211 merged commit 07018f2 into main Aug 4, 2026
3 checks passed
@Jammy2211
Jammy2211 deleted the feature/spawn-template-index-selfheal-drift branch August 4, 2026 17:57
Jammy2211 added a commit that referenced this pull request Aug 4, 2026
…PR (#126)

* feat: wire the Spawn Drift self-heal — regenerate and propose a sync PR

Spawn Drift detected drift but nothing regenerated. The templates are declared a
generated view, yet the only thing that ever regenerated them was a human typing
`/spawn --apply` — so the scheduled leg went red, stayed red, and was cleared by
hand. Every green run in its history was a manual dispatch fired 16-19 seconds
after such a sync, which is why it could not fail.

On schedule/dispatch the workflow now regenerates and opens (or refreshes) a
sync PR on each drifted template repo.

Deliberately a PR, not a bot push. These repos are force-synced generated views,
so an automated push would be a force-push to a published `main`; #118 — a leak
that sat public for eight days — is the argument for a human seeing what gets
published. That keeps the sanctioned force-push a human act while removing the
"nothing regenerates" gap.

## The safety interlock

`--check` collapsed every failure into exit 1. Split into:

  0 CLEAN   published matches the regenerated tree
  1 DRIFT   content differs — mechanical, safe to PROPOSE
  2 UNSAFE  UNMATCHED file class or canary hit — a HUMAN DECISION

Only exit 1 reaches the PR path. Exit 2 fails the job and opens nothing: a
canary hit means the regenerated tree carries live instance content, so a sync
PR would be proposing to publish a leak — #118 with a robot doing it. A test
asserts UNSAFE outranks DRIFT when both are present.

## A latent bug this surfaced

Running the workflow's shell rather than only reading it found that
`stamp_complete_index()` (from #120) breaks on a RELATIVE `--write DIR`: the
child resolves the script path after chdir'ing to `cwd`, so `--write
regenerated` — exactly what a CI step naturally passes — died with "can't open
file". Every invocation to date happened to use an absolute path, so it stayed
latent. Fixed by resolving, with a regression test that fails without it.

## Verified by executing it, not just reading it

Both `run:` blocks were extracted and run under `bash -e` (as Actions runs
them) against a CI-shaped fixture, with `git push`/`gh` stubbed:

  * clean   -> no PR
  * drift   -> PR opened; the already-current repo correctly skipped
  * drift   -> existing PR REFRESHED, no duplicate (stable force-updated branch)
  * canary  -> job fails, code=2, PR step's `if` is false, nothing proposed

## Known external dependency

Opening a PR on the template repos needs write access there; GITHUB_TOKEN is
scoped to PyAutoMind. Uses `secrets.PAT_PYAUTOLABS`, the org's established
cross-repo token. Whether that PAT grants write to the two template repos
CANNOT be verified from a local session — only a real run reveals it. The step
therefore fails with an explicit, actionable message rather than silently doing
nothing.

Refs #125

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: a spawn crash must not be reported as drift

Python exits 1 on an unhandled exception — the same code as EXIT_DRIFT. The
self-heal would therefore read a crash as "the templates are stale" and try to
propose a sync PR from whatever partial tree the crash left behind.

Not hypothetical: stamp_complete_index() crashing on a relative --write path
(fixed in the previous commit) produced exactly this exit-1-that-means-crash,
and that is how it was noticed.

Unhandled exceptions now exit 3, which the workflow's catch-all rejects. The
traceback is still printed, so nothing is hidden.

Refs #125

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address independent review of the self-heal (6 findings)

1. HIGH — the change broke rule 9, which it had just established one PR ago.
   Adding the PAT-dependent proposal step to spawn_drift.yml meant the template
   would ship `secrets.PAT_PYAUTOLABS`, violating the no-configured-secret
   condition. Confirmed against a post-merge generation.

   Root cause in the TESTS, not just the rules: the .github fixtures were
   hand-written miniatures, so `test_no_shipped_workflow_needs_a_configured_secret`
   was checking a toy workflow that had no PAT while the real one had grown one.
   The fixtures now READ THE REAL WORKFLOW FILES. Control-tested: shipping
   spawn_drift.yml again now fails four tests, including the secret check.

   Rule 9b revised KEEP-with-schedule-stripped -> DROP. The self-heal makes the
   workflow depend on a PAT and on published *-template repos; a fresh org has
   neither, so every path in it is unrunnable there. "When in doubt DROP" —
   the generator and its guards still travel via scripts/ and tests/. The
   schedule-stripping transform is retired as dead code with its tests.

2. HIGH — `gh pr view` matches merged and closed PRs, so once a sync PR was
   merged the reused branch would report "refreshed" forever and silently never
   open another. Now `gh pr list --state open --head`. Both paths dry-run
   tested: open -> refresh, merged -> opens a new PR.

3. HIGH — every exit-code test compared a subprocess result with constants from
   the same module, so swapping EXIT_DRIFT and EXIT_UNSAFE would have left them
   green while the workflow still auto-proposed literal exit 1. Two tests now
   read the real workflow and pin the CONSUMER against the producer.
   Control-tested by swapping the constants.

4. MEDIUM — the interlock was incomplete. Fail-closed paths raise
   SystemExit("message"), which Python turns into exit 1: indistinguishable
   from EXIT_DRIFT. Those are human decisions like UNMATCHED, so they now map
   to EXIT_UNSAFE. The earlier crash guard only covered exceptions.

5. MEDIUM — concurrent runs were last-writer-wins on the shared proposal
   branch. Added a `concurrency` group that queues rather than cancels; a
   cancelled run could leave a pushed branch with no PR.

6. LOW — `diff` exit 2 means trouble, not differences, but every nonzero status
   entered the drift path, so an unreadable tree would trigger replacement and
   force-push. Only exit 1 is drift now; 2 aborts.

Refs #125

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Jammy2211 <JNightingale2211@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pending-release Awaiting coordinated release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant